fix(dashboard): exit edit mode when navigating away (#1037)#1089
Conversation
Extract edit mode state from view-local to a centralized provider so it can be accessed by route guards. When user navigates away from dashboard (e.g., tab switch) during edit mode, onExit cancels edit mode and reverts any unsaved layout changes. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Qodo reviews are paused for this user.Troubleshooting steps vary by plan Learn more → On a Teams plan? Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center? |
AustinChangLinksys
left a comment
There was a problem hiding this comment.
🤖 Automated Review — Round 1 · 93e76ca..c513381 (full)
Verdict: 💬 Self-review (comment only) — Own PR; 0 Criticals; 5 Warnings (re-entrant guard, TOCTOU race, missing error handling, silent discard, naming trap), 2 Suggestions.
| Conf. | Where | Issue (one-liner) | |
|---|---|---|---|
| 🟢High | dashboard_edit_mode_provider.dart:enterEditMode |
[single, Reviewer A] Re-entrant call overwrites original layout snapshot — lose true pre-edit baseline | |
| 🟡Med | dashboard_edit_mode_provider.dart:enterEditMode + route_usp_dashboard.dart:onExit |
[single, Reviewer A] TOCTOU: onExit fires in async gap before isEditing=true; controller stranded in edit mode |
|
| 🟡Med | route_usp_dashboard.dart:onExit |
[single, Reviewer A] No try-catch; cancelEditMode exception leaves isEditing=true permanently |
|
| 🟢High | route_usp_dashboard.dart:onExit |
[single, Reviewer B] Silent discard on nav-away diverges from codebase-wide showUnsavedAlert pattern |
|
| 🟡Med | dashboard_edit_mode_provider.dart:exitEditMode |
[single, Reviewer B] save=false naming is inverted-boolean trap — rename to revert/discardChanges |
|
| 💡 | 🟢High | dashboard_edit_mode_provider.dart:DashboardEditState |
[both reviewers] layoutSnapshot: List<dynamic>? weak type; should be List<Map<String, dynamic>>? |
| 💡 | 🟢High | test/page/dashboard/providers/dashboard_edit_mode_provider_test.dart |
[both reviewers] No integration test covering onExit guard, re-entrant enterEditMode, or TOCTOU race |
Confidence: 🟢High = code-verified · 🟡Med = located + reasoned, not fully confirmed · ⚪Low = speculative, please double-check.
Items marked [both reviewers] were independently flagged by two agents → higher confidence.
⚠️ Warning Details
W-1 [single, Reviewer A] · 🟢High — Re-entrant enterEditMode silently overwrites the original snapshot
lib/page/dashboard/providers/dashboard_edit_mode_provider.dart — enterEditMode():
Future<void> enterEditMode() async {
await ref.read(uspLayoutPreferencesProvider.notifier).initialized;
final controller = ref.read(uspSliverDashboardControllerProvider);
final layoutSnapshot = controller.exportLayout(); // ← re-captures already-modified grid
state = DashboardEditState(
isEditing: true,
layoutSnapshot: layoutSnapshot, // ← unconditionally overwrites original baseline
...
);
}No if (state.isEditing) return guard. A double-tap or gesture race calls enterEditMode() while already editing, re-capturing the current (already-modified) layout as the new "original" snapshot. Tapping Cancel or navigating away then reverts to the mid-edit state, not the true pre-edit baseline.
Fix: if (state.isEditing) return; as the first line.
W-2 [single, Reviewer A] · 🟡Med — TOCTOU: onExit fires in async gap before isEditing=true
enterEditMode() sets isEditing=true only after await initialized. If the user taps Edit then immediately navigates away before initialized resolves, onExit reads isEditing=false and skips cancelEditMode(). enterEditMode then resumes and sets isEditing=true — the controller is permanently stuck in edit mode on a different page.
Fix: Set state = state.copyWith(isEditing: true) before the await to claim the slot early; onExit will then always see the correct state.
W-3 [single, Reviewer A] · 🟡Med — No try-catch in onExit; exception leaves stale isEditing=true
lib/route/route_usp_dashboard.dart:onExit:
onExit: (context, state) async {
if (editState.isEditing) {
await container.read(dashboardEditModeProvider.notifier).cancelEditMode(); // unguarded
}
return true;
}cancelEditMode() calls saveLayout() and restoreSnapshot(), both of which can throw (SharedPreferences failure, platform exception). An unhandled exception leaves dashboardEditModeProvider.isEditing=true permanently; the next dashboard visit has stale state.
Fix: Wrap in try-catch and force-reset provider state on error.
W-4 [single, Reviewer B] · 🟢High — Silent discard diverges from showUnsavedAlert pattern
Every other editable route in the codebase (uspInstantSafety, uspDhcpDetail, uspWifiSettings, etc.) uses enableDirtyCheck: true / showUnsavedAlert before discarding unsaved changes. The dashboard onExit silently calls cancelEditMode() without prompting. A user who navigated away accidentally after rearranging the dashboard loses all work without warning.
Fix: Either use showUnsavedAlert(context) before cancelEditMode(), or add an explicit code comment documenting the intentional silent-discard policy.
W-5 [single, Reviewer B] · 🟡Med — save=false naming is inverted-boolean trap
exitEditMode({bool save = true}) — the save=false branch calls controller.importLayout(snapshot) and then still calls saveLayout() (persists the reverted snapshot to disk). The parameter name says "don't save" but a save operation executes inside the !save branch.
Fix: Rename to exitEditMode({bool revert = false}), or expose only cancelEditMode() / commitEditMode() as the public API and make exitEditMode private.
✅ What looks good
- Full migration confirmed: Synthesizer re-read the complete diff — all 7
_isEditModereferences inusp_sliver_dashboard_view.dart(_buildHeader,_buildSliverDashboard×2,trashBuilder×2,_buildItemWidget×2,_openLayoutSettings) are updated to useref.watch(dashboardEditModeProvider).isEditing. No stale local-state references remain. - Architecture compliance: Three-layer rules respected. Provider imports only models and sibling providers; view imports only the new provider. No codegen model imports. No custom widgets introduced.
- Provider rules:
NotifierProvider(non-autoDispose, correct L1). Allref.readinside notifier methods (correct L2 pattern). NouspMutationLockProviderneeded (local preference writes, not USP API mutations).PreservableContractnot redefined. - Route guard idiom:
ProviderScope.containerOf(context)is the idiomatic pattern for accessing Riverpod providers in go_router callbacks outside widget tree. - Unit test coverage:
dashboard_edit_mode_provider_test.dart(172 lines) covers state model, enter, exit (save/revert), cancel, and multi-cycle stability. Solid provider-level unit coverage. - No security issues: No hardcoded secrets, injection surfaces, permission bypasses, or unsafe data handling.
Cross-reviewed by two independent agents (security+correctness / architecture+maintainability). Automated — please sanity-check before merge.
PeterJhongLinksys
left a comment
There was a problem hiding this comment.
_openLayoutSettings reverts the reset/preset change it's meant to keep
In usp_sliver_dashboard_view.dart, when the layout-settings dialog returns 'reset' / 'toggle_off' / 'preset_changed', the handler now calls exitEditMode(save: false). The inline comment says "the settings panel already applied the reset/preset change directly" — but save: false does the opposite of preserving it.
In dashboard_edit_mode_provider.dart, the save == false branch runs controller.importLayout(state.layoutSnapshot!) + saveLayout() and restoreSnapshot(state.prefsSnapshot!), i.e. it reverts to the pre-edit snapshot captured in enterEditMode. Since resetToDefaults() / selectPreset() have already mutated the controller/prefs to the new state, this call undoes the user's just-applied reset / preset-change / toggle-off. The snapshots are always populated (enterEditMode sets them unconditionally) and the settings button is only reachable in edit mode, so the revert always fires.
The previous code intentionally only cleared the edit flag and nulled the snapshots to keep the applied change. Suggest exiting edit mode here without the revert path (e.g. a save/commit exit, or clear-state-only), so the reset/preset the user selected is preserved.
(Distinct from the earlier W-5 note, which framed save's naming in the provider rather than this call-site's functional effect.)
) Address PR #1089 review. Root cause: exitEditMode({bool save}) was an inverted-boolean trap — the !save branch persisted the pre-edit snapshot, so the settings-panel reset/preset path (which had already applied its change) called save:false and silently reverted the user's change. - Replace exitEditMode({bool save}) with explicit commitEditMode() (keep changes) and cancelEditMode() (revert to snapshot), sharing a private _exitEditMode({required bool revert}) that always resets state in a finally block (no stranded isEditing=true on save/restore failure) - Route _openLayoutSettings reset/preset path to commitEditMode() so the just-applied change is preserved; drop dead 'toggle_off' branch - enterEditMode: add re-entrant guard and claim isEditing before the await so an onExit firing in the async gap can never strand edit mode - route_usp_dashboard onExit: wrap cancelEditMode in try-catch and document the intentional silent-discard policy - Tighten DashboardEditState.layoutSnapshot to List<Map<String, dynamic>>? - Delete unused lib/route/linksys_route.dart (dead code; active LinksysRoute lives in route_model.dart) - Add regression tests: commit-preserves-changes, re-entrant guard (W-1), cancel-during-async-gap (W-2) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
AustinChangLinksys
left a comment
There was a problem hiding this comment.
🤖 Automated Review — Round 2 · c513381..963db6d (incremental)
Verdict: 💬 Self-review (comment only) — GitHub prohibits self-approval; review posted as comment. 0 Critical; 2 Warnings (partial-revert inconsistency on exception, void-async exception swallowing); 5 Suggestions. All Round-1 warnings confirmed resolved.
| Conf. | Where | Issue (one-liner) | |
|---|---|---|---|
| 🟢High | dashboard_edit_mode_provider.dart:82–101 |
[single, Reviewer A] saveLayout() exception inside _exitEditMode(revert:true) skips restoreSnapshot() → controller memory and prefs left in inconsistent state |
|
| 🟢High | usp_sliver_dashboard_view.dart:115–121 |
[single, Reviewer B] _commitEditMode / _cancelEditMode are void async — exceptions silently discarded, no error log, no observability |
|
| 💡 | 🟢High | dashboard_edit_mode_provider.dart:26–38 |
[single, Reviewer A] copyWith() defined but never called inside the Notifier; only used in tests — risk of future misuse |
| 💡 | 🟢High | dashboard_edit_mode_provider.dart:49–73 |
[single, Reviewer B] Snapshot-null window (isEditing=true but layoutSnapshot=null) between claim and full population is undocumented |
| 💡 | 🟢High | dashboard_edit_mode_provider.dart:89 |
[single, Reviewer B] _exitEditMode lacks idempotency guard — callable when isEditing=false (harmless today, but asymmetric with enterEditMode) |
| 💡 | 🟢High | usp_sliver_dashboard_view.dart:511 |
[single, Reviewer B] toggle_off removal from _openLayoutSettings result handler lacks an explanatory comment (dead code since previous iteration) |
| 💡 | 🟢High | lib/route/linksys_route.dart (deleted) |
[single, Reviewer B] Deletion of dead code is correct; PR description could document the FeatureState.isDirty → PreservableContract evolution for future reference |
Confidence: 🟢High = code-verified · 🟡Med = located + reasoned, not fully confirmed · ⚪Low = speculative, please double-check.
Items marked [both reviewers] were independently flagged by two agents → higher confidence.
✅ Round-1 Resolutions Confirmed
All 7 Round-1 findings are fully resolved in this diff:
- W-1 Re-entrant enterEditMode — ✅
if (state.isEditing) return;guard added atdashboard_edit_mode_provider.dart:50. - W-2 TOCTOU race — ✅
state = const DashboardEditState(isEditing: true)set before theawait(line 54); post-awaitif (!state.isEditing) return;bail-out added (line 60). - W-3 No try-catch in onExit — ✅
try/catchwrapscancelEditMode()inroute_usp_dashboard.dartwithlogger.eon failure. - W-4 Silent-discard vs. showUnsavedAlert — ✅ Intentional-policy comment added in
route_usp_dashboard.dart:21–29. - W-5
save=falseinverted-boolean naming — ✅ API renamed tocommitEditMode()/cancelEditMode()/_exitEditMode({required bool revert}). - S-1
List<dynamic>?weak type — ✅ Upgraded toList<Map<String, dynamic>>?. - S-2 Missing integration tests — ✅ New tests added:
commitEditMode-preserves-changes, W-1 re-entrant regression, W-2 TOCTOU cancel-during-async-gap. - PeterJhong comment (_openLayoutSettings reset/preset reverts change) — ✅
_openLayoutSettingsnow callscommitEditMode()forreset/preset_changed(keeps the applied change instead of reverting);toggle_offbranch removed (was dead code — settings panel never emitted this string). - linksys_route.dart deletion — ✅ Confirmed safe: file was never imported anywhere; active
LinksysRoutelives inroute_model.dartusingPreservableContract.
⚠️ Warning Details
W-1 [single, Reviewer A] · 🟢High — Partial revert on saveLayout() exception leaves layout/prefs inconsistent
lib/page/dashboard/providers/dashboard_edit_mode_provider.dart:82–101
Future<void> _exitEditMode({required bool revert}) async {
final controller = ref.read(uspSliverDashboardControllerProvider);
try {
if (revert) {
if (state.layoutSnapshot != null) {
controller.importLayout(state.layoutSnapshot!); // Step 1: in-memory revert OK
await ref
.read(uspSliverDashboardControllerProvider.notifier)
.saveLayout(); // Step 2: can throw
}
if (state.prefsSnapshot != null) {
await ref
.read(uspLayoutPreferencesProvider.notifier)
.restoreSnapshot(state.prefsSnapshot!); // Step 3: skipped on Step 2 throw
}
}
} finally {
controller.setEditMode(false);
state = const DashboardEditState(); // snapshots cleared, no retry possible
}
}If saveLayout() throws (SharedPreferences I/O failure), execution jumps to finally, skipping restoreSnapshot(). Resulting state:
| Layer | State after exception |
|---|---|
| Controller (in-memory) | Pre-edit layout (visually reverted) |
| Persistent storage | Last autosave during edit (NOT reverted) |
| Prefs (memory + storage) | Post-edit state (NOT reverted) |
The user sees the layout reverted but prefs remain in the edited state. On app restart the combination can produce inconsistent UI. The finally comment only guarantees "edit mode won't be stranded" — it does not protect layout/prefs consistency.
Trigger: Any I/O error in saveLayout() during a cancel/nav-away (uncommon, not impossible).
Fix:
try {
if (revert) {
if (state.layoutSnapshot != null) {
controller.importLayout(state.layoutSnapshot!);
try {
await ref.read(uspSliverDashboardControllerProvider.notifier).saveLayout();
} catch (e, s) {
logger.e('[Dashboard] saveLayout failed during revert; continuing to restore prefs',
error: e, stackTrace: s);
// continue — restoreSnapshot must still run to keep prefs consistent
}
}
if (state.prefsSnapshot != null) {
await ref.read(uspLayoutPreferencesProvider.notifier).restoreSnapshot(state.prefsSnapshot!);
}
}
} finally {
controller.setEditMode(false);
state = const DashboardEditState();
}W-2 [single, Reviewer B] · 🟢High — void async in view-layer wrappers silently discards exceptions
lib/page/dashboard/views/usp_sliver_dashboard_view.dart:115–121
void _commitEditMode() async {
await ref.read(dashboardEditModeProvider.notifier).commitEditMode();
}
void _cancelEditMode() async {
await ref.read(dashboardEditModeProvider.notifier).cancelEditMode();
}In Dart, void async functions discard the Future; any uncaught exception surfaces only through FlutterError.onError or the zone's uncaught-error handler. In production, errors in commitEditMode() / cancelEditMode() become invisible. The _exitEditMode finally block guarantees state is reset, so no functional regression — but failure observability is lost.
This pattern (_enterEditMode already uses it) is an existing convention in the view; the new methods extend it. Not blocking, but an opportunity to improve diagnostics.
Fix (non-breaking):
void _commitEditMode() {
ref.read(dashboardEditModeProvider.notifier).commitEditMode()
.catchError((e, s) => logger.e('[Dashboard] commitEditMode failed', error: e, stackTrace: s));
}
void _cancelEditMode() {
ref.read(dashboardEditModeProvider.notifier).cancelEditMode()
.catchError((e, s) => logger.e('[Dashboard] cancelEditMode failed', error: e, stackTrace: s));
}✅ What looks good
- Core logic is correct end-to-end — re-entrant guard, TOCTOU fix, API rename,
_openLayoutSettingscommit path,toggle_offdead-code removal all confirmed correct by reading actual source. - Architecture compliance —
NotifierProvider(non-autoDispose), allref.readinside notifier, no USP mutation lock needed (pure local-prefs writes), no codegen imports, no custom widgets. Fully compliant with three-layer and provider rules. finallyblock guarantees no stuck edit-mode — even if revert operations partially fail, the controller and provider state are always reset. The main gap (W-1) is about data consistency, not edit-mode being stranded.- Test suite is comprehensive at the provider level — re-entrant, TOCTOU, commit-preserves-changes, multi-cycle, cancel-revert all covered. Strong regression prevention.
linksys_route.dartdeletion confirmed safe —route_model.dartis the sole activeLinksysRoutedefinition; the deleted file was never imported; no duplicate-class risk.- No security issues — no hardcoded secrets, no injection surfaces, no permission bypasses, no JNAP/USP response handling changes.
Cross-reviewed by two independent agents (security+correctness / architecture+maintainability). Automated — please sanity-check before merge.
AustinChangLinksys
left a comment
There was a problem hiding this comment.
🤖 Automated Review — Round 3 · c513381..963db6d (incremental)
Verdict: 💬 Self-review (comment only) — GitHub prohibits self-approval. PeterJhong's _openLayoutSettings bug confirmed resolved in this diff. Two carry-over Warnings (W-1 data-inconsistency, W-2 void-async observability) and one new Warning (N-1 async-gap no-op) remain open.
| Conf. | Where | Issue (one-liner) | |
|---|---|---|---|
| 🟢High | dashboard_edit_mode_provider.dart:82–101 |
[single, Reviewer A, CARRY-OVER W-1] saveLayout() exception skips restoreSnapshot() — prefs not reverted on I/O failure |
|
| 🟢High | usp_sliver_dashboard_view.dart:115–121 |
[both reviewers, CARRY-OVER W-2] _commitEditMode / _cancelEditMode are void async — exceptions silently discarded |
|
| 🟡Med | dashboard_edit_mode_provider.dart:47–72 |
[single, Reviewer A] cancelEditMode during enterEditMode's async gap is a silent no-op (snapshots null — upgrade from prior S-2) |
|
| 💡 | 🟢High | dashboard_edit_mode_provider.dart:89 |
[both reviewers] _exitEditMode lacks idempotency guard symmetric with enterEditMode's re-entrant check |
| 💡 | 🟡Med | usp_sliver_dashboard_view.dart:401 |
[single, Reviewer B] _buildTrashZone uses bare Container/BoxDecoration instead of ui_kit_library — pre-existing tech debt |
| 💡 | ⚪Low | usp_sliver_dashboard_view.dart:514 |
[single, Reviewer B] toggle_off silently unhandled if re-added to settings panel in future |
| 💡 | 🟢High | test/.../dashboard_edit_mode_provider_test.dart |
[single, Reviewer B] No test for cancelEditMode/commitEditMode invoked when isEditing==false (idempotency) |
Confidence: 🟢High = code-verified · 🟡Med = located + reasoned, not fully confirmed · ⚪Low = speculative, please double-check.
Items marked [both reviewers] were independently flagged by two agents → higher confidence.
⚠️ Warning Details
W-1 [single, Reviewer A, CARRY-OVER] · 🟢High — saveLayout() exception skips restoreSnapshot()
lib/page/dashboard/providers/dashboard_edit_mode_provider.dart:82–101
Future<void> _exitEditMode({required bool revert}) async {
final controller = ref.read(uspSliverDashboardControllerProvider);
try {
if (revert) {
if (state.layoutSnapshot != null) {
controller.importLayout(state.layoutSnapshot!); // Step 1: in-memory revert OK
await ref
.read(uspSliverDashboardControllerProvider.notifier)
.saveLayout(); // Step 2: can throw
}
if (state.prefsSnapshot != null) {
await ref
.read(uspLayoutPreferencesProvider.notifier)
.restoreSnapshot(state.prefsSnapshot!); // Step 3: SKIPPED on Step 2 throw
}
}
} finally {
controller.setEditMode(false);
state = const DashboardEditState();
}
}The try/finally added in this commit fixes "edit mode won't be stranded" but does NOT fix the data-consistency gap. If saveLayout() throws:
| Layer | State after exception |
|---|---|
| In-memory layout | Reverted (importLayout ran) |
| Persistent layout | Not reverted (saveLayout failed) |
| In-memory prefs | Not reverted (restoreSnapshot skipped) |
| UI state | Cleared (finally block) |
User sees layout visually reverted but prefs remain in post-edit state; on app restart the combination produces inconsistent UI. Trigger: SharedPreferences I/O failure during cancelEditMode (uncommon, not impossible).
Fix: Wrap saveLayout() in its own inner try-catch so restoreSnapshot() always runs:
if (state.layoutSnapshot != null) {
controller.importLayout(state.layoutSnapshot!);
try {
await ref.read(uspSliverDashboardControllerProvider.notifier).saveLayout();
} catch (e, s) {
logger.e('[Dashboard] saveLayout failed during revert; continuing to restoreSnapshot',
error: e, stackTrace: s);
}
}
if (state.prefsSnapshot != null) {
await ref.read(uspLayoutPreferencesProvider.notifier)
.restoreSnapshot(state.prefsSnapshot!);
}W-2 [both reviewers, CARRY-OVER] · 🟢High — void async view-layer wrappers silently discard exceptions
lib/page/dashboard/views/usp_sliver_dashboard_view.dart:115–121
void _commitEditMode() async {
await ref.read(dashboardEditModeProvider.notifier).commitEditMode();
}
void _cancelEditMode() async {
await ref.read(dashboardEditModeProvider.notifier).cancelEditMode();
}Both remain void async with no try-catch. Exceptions from the notifier are silently swallowed. Compare: route_usp_dashboard.dart's onExit already wraps cancelEditMode() in try-catch with logger.e. The UI-button call sites (✓ commit, ✗ cancel buttons) lack equivalent protection.
Fix (non-breaking):
void _commitEditMode() async {
try {
await ref.read(dashboardEditModeProvider.notifier).commitEditMode();
} catch (e, s) {
logger.e('[Dashboard] commitEditMode failed', error: e, stackTrace: s);
}
}
void _cancelEditMode() async {
try {
await ref.read(dashboardEditModeProvider.notifier).cancelEditMode();
} catch (e, s) {
logger.e('[Dashboard] cancelEditMode failed', error: e, stackTrace: s);
}
}N-1 [single, Reviewer A] · 🟡Med — cancelEditMode during enterEditMode's async gap is a silent no-op
lib/page/dashboard/providers/dashboard_edit_mode_provider.dart:47–72
Future<void> enterEditMode() async {
if (state.isEditing) return;
state = const DashboardEditState(isEditing: true); // snapshots still null here
await ref.read(uspLayoutPreferencesProvider.notifier).initialized; // async gap
// If cancelEditMode() fires HERE: snapshots are null, _exitEditMode(revert:true)
// skips both importLayout and restoreSnapshot — effectively a no-op.
// This is SAFE because controller.setEditMode(true) hasn't been called yet,
// but the invariant is undocumented.
if (!state.isEditing) return; // bail-out fires correctly, no stuck state
// ... capture snapshots, setEditMode(true)
}The if (!state.isEditing) return guard correctly prevents stuck state. The silent no-op is functionally safe (controller not yet in edit mode, no data to revert). Risk is low in practice (window is as long as initialized is pending, typically near-zero on warm start). The TOCTOU test does not exercise a genuinely-pending initialized.
Suggest: Add code comment explaining the null-snapshot invariant so future maintainers understand the silent no-op is intentional.
✅ What looks good (including PeterJhong resolution)
- PeterJhong's
_openLayoutSettingsfix confirmed —reset/preset_changedpaths now correctly callcommitEditMode(), preserving the user's just-applied change.toggle_offdead-code removed correctly (settings panel never emits this string at HEAD — verified). - Re-entrant guard and TOCTOU fix confirmed —
if (state.isEditing) return;+ pre-awaitstate claim + post-awaitbail-out are correct, symmetric, and covered by regression tests. finallyblock guarantees no stuck edit-mode — even ifsaveLayout()andrestoreSnapshot()both fail,setEditMode(false)andstate = const DashboardEditState()always execute.- Route guard error handling —
cancelEditMode()inroute_usp_dashboard.dart:onExitcorrectly wrapped intry-catch; navigation never blocked. - Test suite additions —
commitEditMode preserves layout changes(PeterJhong regression), re-entrant guard, TOCTOU cancel-during-async-gap, multi-cycle tests comprehensively cover the corrected behavior. - Architecture compliance —
NotifierProvider(non-autoDispose), allref.readinside notifier, correct 3-layer boundaries maintained throughout. - No security issues — no hardcoded secrets, no injection vectors, no permission changes, no JNAP/USP response handling modifications.
Cross-reviewed by two independent agents (security+correctness / architecture+maintainability). Automated — please sanity-check before merge.
AustinChangLinksys
left a comment
There was a problem hiding this comment.
🤖 Automated Review — Round 4 · c513381..963db6d (incremental)
Verdict: 💬 Self-review (comment only) — GitHub prohibits self-approval. Good progress this round: PeterJhong's _openLayoutSettings reset/preset fix confirmed, re-entrant guard and async-gap cancel correctly handled and tested, toggle_off dead code removed, stale linksys_route.dart deleted. Two carry-over Warnings and two new Warnings remain open.
| Conf. | Where | Issue (one-liner) | |
|---|---|---|---|
| 🟢High | dashboard_edit_mode_provider.dart:94–103 |
[CARRY-OVER W-1, single] saveLayout() exception skips restoreSnapshot() — prefs not reverted on I/O failure, grid and prefs left inconsistent |
|
| 🟢High | usp_sliver_dashboard_view.dart:115–119 |
[CARRY-OVER W-2, both reviewers] _commitEditMode/_cancelEditMode remain void async — exceptions from button-tap path silently discarded |
|
| 🟢High | dashboard_edit_mode_provider.dart:89 |
[NEW, both reviewers] _exitEditMode lacks isEditing guard — unnecessary controller updates on redundant calls; concurrent cancel+commit race risk |
|
| 🟢High | test/.../dashboard_edit_mode_provider_test.dart |
[NEW, single, Reviewer B] Missing isEditing==false idempotency tests for commitEditMode/cancelEditMode |
|
| 💡 | 🟡Med | usp_sliver_dashboard_view.dart:514–519 |
[single, Reviewer B] _openLayoutSettings result handler lacks exhaustive comment — silent skip if toggle_off is re-added |
| 💡 | 🟡Med | dashboard_edit_mode_provider.dart:106–108 |
[single, Reviewer B] finally captures controller ref before awaits — stale if controller replaced during revert |
| 💡 | ⚪Low | dashboard_edit_mode_provider.dart:17 |
[single] layoutSnapshot narrowed to List<Map<String,dynamic>> but usp_layout_controller.dart still casts to List<dynamic> — partial type alignment |
Confidence: 🟢High = code-verified · 🟡Med = located + reasoned, not fully confirmed · ⚪Low = speculative, please double-check.
Items marked [both reviewers] were independently flagged by two agents → higher confidence.
⚠️ Warning Details
W-1 [CARRY-OVER, single, Reviewer A] · 🟢High — saveLayout() exception skips restoreSnapshot()
lib/page/dashboard/providers/dashboard_edit_mode_provider.dart:89–109
Future<void> _exitEditMode({required bool revert}) async {
final controller = ref.read(uspSliverDashboardControllerProvider);
try {
if (revert) {
if (state.layoutSnapshot != null) {
controller.importLayout(state.layoutSnapshot!); // Step 1: in-memory revert
await ref
.read(uspSliverDashboardControllerProvider.notifier)
.saveLayout(); // Step 2: can throw
}
if (state.prefsSnapshot != null) {
await ref
.read(uspLayoutPreferencesProvider.notifier)
.restoreSnapshot(state.prefsSnapshot!); // Step 3: SKIPPED if Step 2 throws
}
}
} finally {
controller.setEditMode(false);
state = const DashboardEditState(); // stuck-mode fixed — consistency gap remains
}
}The Round 3 try/finally correctly fixed stuck edit-mode. But the data-consistency gap is unchanged: if saveLayout() throws, control jumps to finally, skipping restoreSnapshot(). After failure:
| Layer | State |
|---|---|
| In-memory grid | Reverted (importLayout ran) |
| Persisted layout | Not reverted (saveLayout failed) |
| In-memory prefs | Not reverted (restoreSnapshot skipped) |
| UI state | Cleared (finally) |
User sees grid visually reverted but prefs remain mid-edit; on restart the combination produces inconsistent UI. Trigger: SharedPreferences I/O failure during cancelEditMode.
Fix: Wrap saveLayout() in its own inner try-catch so restoreSnapshot() always executes:
if (state.layoutSnapshot != null) {
controller.importLayout(state.layoutSnapshot!);
try {
await ref.read(uspSliverDashboardControllerProvider.notifier).saveLayout();
} catch (e, s) {
logger.e('[Dashboard] saveLayout failed during revert; continuing to restoreSnapshot',
error: e, stackTrace: s);
}
}
if (state.prefsSnapshot != null) {
await ref.read(uspLayoutPreferencesProvider.notifier)
.restoreSnapshot(state.prefsSnapshot!);
}W-2 [CARRY-OVER, both reviewers] · 🟢High — void async view wrappers silently discard exceptions from button taps
lib/page/dashboard/views/usp_sliver_dashboard_view.dart:115–119
void _commitEditMode() async {
await ref.read(dashboardEditModeProvider.notifier).commitEditMode();
}
void _cancelEditMode() async {
await ref.read(dashboardEditModeProvider.notifier).cancelEditMode();
}Both remain void async with no try-catch. The route_usp_dashboard.dart:onExit path now has a try-catch (good), but the check/close button paths (onTap: _commitEditMode, onTap: _cancelEditMode) do not. If saveLayout() throws from a button tap, the user sees no feedback and no error is logged from the view layer.
Fix:
void _commitEditMode() async {
try {
await ref.read(dashboardEditModeProvider.notifier).commitEditMode();
} catch (e, s) {
logger.e('[Dashboard] commitEditMode failed', error: e, stackTrace: s);
}
}
void _cancelEditMode() async {
try {
await ref.read(dashboardEditModeProvider.notifier).cancelEditMode();
} catch (e, s) {
logger.e('[Dashboard] cancelEditMode failed', error: e, stackTrace: s);
}
}W-3 [NEW, both reviewers] · 🟢High — _exitEditMode lacks isEditing idempotency guard
lib/page/dashboard/providers/dashboard_edit_mode_provider.dart:89
enterEditMode() has if (state.isEditing) return; but _exitEditMode has no symmetric guard. If both _cancelEditMode (button) and onExit (route) fire concurrently in an async gap:
- First call starts
_exitEditMode(revert: true),importLayoutruns,saveLayout()awaiting. - Second call enters
_exitEditMode(revert: true)—state.layoutSnapshotis null (first call'sfinallyalready cleared state), soimportLayout/restoreSnapshotskipped, butcontroller.setEditMode(false)andstate = const DashboardEditState()fire again — triggering unnecessary Riverpod listener notifications.
While the finally guarantees correct end state, the duplicate controller/state updates are observable side-effects.
Fix:
Future<void> _exitEditMode({required bool revert}) async {
if (!state.isEditing) return; // symmetric guard matching enterEditMode
final controller = ref.read(uspSliverDashboardControllerProvider);
// ... rest unchanged
}W-4 [NEW, single, Reviewer B] · 🟢High — Missing isEditing==false idempotency unit tests
test/page/dashboard/providers/dashboard_edit_mode_provider_test.dart
This round added four valuable tests (re-entrant guard, async-gap cancel, PeterJhong regression, multi-cycle). However, none directly tests calling commitEditMode() or cancelEditMode() when isEditing == false. If W-3's guard is added, this path becomes a documented early-return; without a test, future refactors could break it silently.
Fix: Add:
test('commitEditMode when not editing is a safe no-op', () async {
final container = await createContainer();
await container.read(dashboardEditModeProvider.notifier).commitEditMode();
expect(container.read(dashboardEditModeProvider).isEditing, isFalse);
});
test('cancelEditMode when not editing is a safe no-op', () async {
final container = await createContainer();
await container.read(dashboardEditModeProvider.notifier).cancelEditMode();
expect(container.read(dashboardEditModeProvider).isEditing, isFalse);
});✅ What looks good (including this round's resolutions)
- PeterJhong's
_openLayoutSettingsfix confirmed —reset/preset_changednow correctly callcommitEditMode()(keeps the applied change).toggle_offdead code removed cleanly. - Re-entrant
enterEditModeguard —if (state.isEditing) return;prevents snapshot overwrite on double-tap; state claimed beforeawaitensuresonExitalways observes edit state. - Async-gap cancel covered —
if (!state.isEditing) return;post-await bail-out correct, now has a dedicated test. finallyblock guarantees no stuck edit-mode —setEditMode(false)andstate = DashboardEditState()always execute regardless of I/O failures.linksys_route.dartdeleted — DuplicateLinksysRoutesubclass using deprecatedFeatureState.isDirtyAPI removed;route_model.dartis now the single definition.route_usp_dashboard.dartonExithardening —cancelEditMode()wrapped intry-catchwithlogger.e; navigation never blocked on revert failure.layoutSnapshottype narrowed —List<dynamic>?toList<Map<String,dynamic>>?correctly reflectsDashboardController.exportLayout()return type.- API rename (commit/cancel) — Clearer semantics than
save: boolparameter; all call sites updated. - Architecture compliance —
NotifierProvider(non-autoDispose), allref.readinside notifier, 3-layer boundaries maintained. - Test suite additions — Re-entrant guard, TOCTOU cancel-during-gap, PeterJhong regression, multi-cycle tests comprehensively cover the corrected behavior.
- No security issues — No hardcoded secrets, no injection vectors, no JNAP/USP response handling modifications.
Cross-reviewed by two independent agents (security+correctness / architecture+maintainability). Automated — please sanity-check before merge.
AustinChangLinksys
left a comment
There was a problem hiding this comment.
🤖 Automated Review — Round 2 · c513381..963db6d (incremental)
Verdict: 💬 Self-review (comment only) — GitHub prohibits self-approval. PeterJhong's _openLayoutSettings commit/revert regression correctly fixed this round. Four carry-over Warnings remain open; one new Warning added.
| Conf. | Where | Issue (one-liner) | |
|---|---|---|---|
| High | dashboard_edit_mode_provider.dart:94-103 |
[both reviewers, CARRY-OVER W-1] saveLayout() exception skips restoreSnapshot() — prefs not reverted on I/O failure, grid/prefs left inconsistent |
|
| High | usp_sliver_dashboard_view.dart:115-122 |
[both reviewers, CARRY-OVER W-2] _commitEditMode/_cancelEditMode remain void async — exceptions from button-tap path silently discarded |
|
| High | dashboard_edit_mode_provider.dart:89 |
[both reviewers, CARRY-OVER W-3] _exitEditMode lacks isEditing guard — concurrent cancel+commit race; redundant controller updates |
|
| Med | test/.../dashboard_edit_mode_provider_test.dart |
[both reviewers, CARRY-OVER W-4] Missing isEditing==false idempotency tests for commitEditMode/cancelEditMode |
|
| High | usp_sliver_dashboard_view.dart:514 |
[both reviewers, NEW W-5] toggle_off result silently dropped — no comment explaining removal; future contributors may re-add it without realising it's now unhandled |
Confidence: High = code-verified · Med = located + reasoned, not fully confirmed · Low = speculative, please double-check.
Items marked [both reviewers] were independently flagged by two agents → higher confidence.
Warning Details
W-1 [both reviewers, CARRY-OVER] · High — saveLayout() exception skips restoreSnapshot()
lib/page/dashboard/providers/dashboard_edit_mode_provider.dart:94-103
try {
if (revert) {
if (state.layoutSnapshot != null) {
controller.importLayout(state.layoutSnapshot!);
await ref
.read(uspSliverDashboardControllerProvider.notifier)
.saveLayout(); // can throw; restoreSnapshot SKIPPED if this throws
}
if (state.prefsSnapshot != null) {
await ref
.read(uspLayoutPreferencesProvider.notifier)
.restoreSnapshot(state.prefsSnapshot!); // never reached on saveLayout throw
}
}
} finally {
controller.setEditMode(false);
state = const DashboardEditState(); // snapshots wiped; no retry possible
}When saveLayout() throws (SharedPreferences I/O failure), finally fires clearing the edit flag and snapshots, but restoreSnapshot never ran. Post-failure: grid in-memory = reverted, grid on-disk = still edited, prefs = still edited. Three sources of truth are inconsistent and unrecoverable.
Fix: Wrap each revert step independently:
if (state.layoutSnapshot != null) {
controller.importLayout(state.layoutSnapshot!);
try {
await ref.read(uspSliverDashboardControllerProvider.notifier).saveLayout();
} catch (e, s) {
logger.e('[Dashboard] saveLayout failed during revert — continuing to restoreSnapshot',
error: e, stackTrace: s);
}
}
if (state.prefsSnapshot != null) {
await ref.read(uspLayoutPreferencesProvider.notifier)
.restoreSnapshot(state.prefsSnapshot!);
}W-2 [both reviewers, CARRY-OVER] · High — void async view wrappers silently discard exceptions
lib/page/dashboard/views/usp_sliver_dashboard_view.dart:115-122
void _commitEditMode() async {
await ref.read(dashboardEditModeProvider.notifier).commitEditMode();
}
void _cancelEditMode() async {
await ref.read(dashboardEditModeProvider.notifier).cancelEditMode();
}Both remain void async with no try-catch. Any exception propagates into an unhandled Future and is silently swallowed. The route onExit path was correctly wrapped in try-catch this round; the button-tap paths were not.
Fix:
void _commitEditMode() async {
try {
await ref.read(dashboardEditModeProvider.notifier).commitEditMode();
} catch (e, s) {
logger.e('[Dashboard]: commitEditMode failed', error: e, stackTrace: s);
}
}
void _cancelEditMode() async {
try {
await ref.read(dashboardEditModeProvider.notifier).cancelEditMode();
} catch (e, s) {
logger.e('[Dashboard]: cancelEditMode failed', error: e, stackTrace: s);
}
}W-3 [both reviewers, CARRY-OVER] · High — _exitEditMode lacks isEditing guard
lib/page/dashboard/providers/dashboard_edit_mode_provider.dart:89
enterEditMode() has if (state.isEditing) return; but _exitEditMode has no symmetric guard. Concurrent path: user taps commit then cancel before saveLayout() returns. Since state.isEditing is still true during the await, the second call enters _exitEditMode(revert: true) and starts a full revert in parallel. Both race to call controller.setEditMode(false) and state = const DashboardEditState(). Outcome: commit followed by an unintended revert. The route onExit is guarded (if (editState.isEditing)); the provider API is not.
Fix:
Future<void> _exitEditMode({required bool revert}) async {
if (!state.isEditing) return; // symmetric guard matching enterEditMode
final controller = ref.read(uspSliverDashboardControllerProvider);
// ... rest unchanged
}W-4 [both reviewers, CARRY-OVER] · Med — Missing isEditing==false idempotency tests
test/page/dashboard/providers/dashboard_edit_mode_provider_test.dart
New tests cover re-entrant guard, async-gap cancel, PeterJhong regression, multi-cycle. None test calling commitEditMode() or cancelEditMode() from an isEditing == false baseline.
Fix:
test('commitEditMode when not editing is a safe no-op', () async {
final container = await createContainer();
await container.read(dashboardEditModeProvider.notifier).commitEditMode();
expect(container.read(dashboardEditModeProvider).isEditing, isFalse);
});
test('cancelEditMode when not editing is a safe no-op', () async {
final container = await createContainer();
await container.read(dashboardEditModeProvider.notifier).cancelEditMode();
expect(container.read(dashboardEditModeProvider).isEditing, isFalse);
});W-5 [both reviewers, NEW] · High — toggle_off result silently dropped without documentation
lib/page/dashboard/views/usp_sliver_dashboard_view.dart:514
- if ((result == 'reset' || result == 'toggle_off' || result == 'preset_changed') && mounted) {
+ if ((result == 'reset' || result == 'preset_changed') && mounted) {
Both reviewers verified that usp_layout_settings_panel.dart currently does not emit toggle_off — so the removal is functionally correct today. However, project documentation lists toggle_off as a valid dialog result. With no comment explaining the removal, a future contributor who re-adds Navigator.pop(context, 'toggle_off') will get a silent no-op: no commit, no cancel — edit mode stays active after the dialog closes.
Fix:
// Note: 'toggle_off' intentionally removed — settings panel no longer emits this
// result as of this PR. If re-added to the panel, update this handler accordingly.
if ((result == 'reset' || result == 'preset_changed') && mounted) {What looks good (including this round's resolutions)
- PeterJhong's
_openLayoutSettingsregression RESOLVED —resetandpreset_changednow correctly callcommitEditMode()(preserve the applied change). PriorexitEditMode(save: false)that inadvertently reverted the user's just-applied change is gone. Regression test added. - Re-entrant
enterEditModeguard confirmed —if (state.isEditing) return;and pre-await state claim correctly prevent snapshot overwrite and handle route-guard async gap. finallyguarantees no stuck edit-mode —setEditMode(false)andstate = DashboardEditState()always execute regardless of I/O failures.route_usp_dashboard.dartonExithardened —cancelEditMode()wrapped in try-catch with logger; navigation never blocked on revert failure.linksys_route.dartdeleted — DuplicateLinksysRouteusing deprecatedFeatureState.isDirtyAPI cleanly removed.layoutSnapshottype narrowed —List<dynamic>?toList<Map<String,dynamic>>?correctly reflectsexportLayout()return type.- API rename (commit/cancel) — Clearer semantics than
save: boolparameter; all call sites updated consistently. - Test suite additions — Re-entrant guard, async-gap cancel, PeterJhong regression, multi-cycle tests comprehensively cover the corrected behavior.
- Architecture compliance —
NotifierProvider(non-autoDispose), allref.readinside notifier, 3-layer boundaries maintained. - No security issues — No hardcoded secrets, no injection vectors, no JNAP/USP response handling modifications.
Cross-reviewed by two independent agents (security+correctness / architecture+maintainability). Automated — please sanity-check before merge.
AustinChangLinksys
left a comment
There was a problem hiding this comment.
🤖 Automated Review — Round 3 · c513381..963db6d (incremental)
Verdict: 💬 Self-review (comment only) — GitHub prohibits self-approval. PeterJhong's _openLayoutSettings regression correctly fixed this round. Three carry-over Warnings remain (W-1, W-2, W-3); W-4 likely unresolved; one new Warning (W-7).
| Conf. | Where | Issue (one-liner) | |
|---|---|---|---|
| High | dashboard_edit_mode_provider.dart:92-108 |
[both reviewers, CARRY-OVER W-1] saveLayout() exception skips restoreSnapshot() — prefs not reverted on I/O failure, grid/prefs left inconsistent |
|
| High | usp_sliver_dashboard_view.dart:115-121 |
[both reviewers, CARRY-OVER W-2] _commitEditMode/_cancelEditMode remain void async — exceptions from button-tap path silently discarded |
|
| High | dashboard_edit_mode_provider.dart:89 |
[both reviewers, CARRY-OVER W-3] _exitEditMode still lacks isEditing guard — concurrent cancel+commit race; enter guard added correctly |
|
| Med | test/.../dashboard_edit_mode_provider_test.dart |
[single, CARRY-OVER W-4] No isEditing==false idempotency tests for commitEditMode/cancelEditMode |
|
| High | usp_sliver_dashboard_view.dart:514 + settings panel |
[single, NEW W-7] Widget added via settings panel then Cancel reverts it — data loss path | |
| 💡 | Med | dashboard_edit_mode_provider.dart:14-30 |
DashboardEditState lacks copyWith — does not follow Type A state model pattern |
Confidence: High = code-verified · Med = located + reasoned, not fully confirmed · Low = speculative, please double-check.
Items marked [both reviewers] were independently flagged by two agents → higher confidence.
Resolved from prior rounds
- PeterJhong regression RESOLVED —
_openLayoutSettingsnow callscommitEditMode()instead ofexitEditMode(save: false), correctly preserving reset/preset changes. Regression test added. - W-5 RESOLVED —
toggle_offremoval contextualised; switching handler tocommitEditMode()makes absence logical. enterEditModere-entrant guard RESOLVED —if (state.isEditing) return;+ earlyisEditing: trueclaim + post-await bail-out correctly handles double-tap race and navigation-during-init async gap.
Warning Details
W-1 [both reviewers, CARRY-OVER] High — saveLayout() exception skips restoreSnapshot()
lib/page/dashboard/providers/dashboard_edit_mode_provider.dart:92-108
try {
if (revert) {
if (state.layoutSnapshot != null) {
controller.importLayout(state.layoutSnapshot!);
await ref.read(uspSliverDashboardControllerProvider.notifier).saveLayout();
// throws here → restoreSnapshot SKIPPED below
}
if (state.prefsSnapshot != null) {
await ref.read(uspLayoutPreferencesProvider.notifier).restoreSnapshot(state.prefsSnapshot!);
}
}
} finally {
controller.setEditMode(false);
state = const DashboardEditState(); // snapshots wiped; no retry possible
}finally correctly prevents stuck edit mode. But if saveLayout() throws (SharedPreferences I/O failure), restoreSnapshot() is skipped. Post-failure: grid in-memory = reverted to snapshot, grid on-disk = still edited, prefs = still reflect edit-mode values. Three subsystems left inconsistent; snapshots wiped in finally so no retry possible.
Fix: Separate try/catch per awaitable so restoreSnapshot always runs:
if (state.layoutSnapshot != null) {
controller.importLayout(state.layoutSnapshot!);
try {
await ref.read(uspSliverDashboardControllerProvider.notifier).saveLayout();
} catch (e, s) {
logger.e('[Dashboard] saveLayout failed during revert', error: e, stackTrace: s);
}
}
if (state.prefsSnapshot != null) {
await ref.read(uspLayoutPreferencesProvider.notifier).restoreSnapshot(state.prefsSnapshot!);
}W-2 [both reviewers, CARRY-OVER] High — _commitEditMode/_cancelEditMode remain void async
lib/page/dashboard/views/usp_sliver_dashboard_view.dart:115-121
void _commitEditMode() async {
await ref.read(dashboardEditModeProvider.notifier).commitEditMode();
}
void _cancelEditMode() async {
await ref.read(dashboardEditModeProvider.notifier).cancelEditMode();
}Both remain void async without try/catch. Any exception propagates into an unhandled Future and is silently swallowed. Route onExit correctly wraps cancelEditMode() in try/catch; button-tap paths do not.
Fix: Add try/catch with logger.e to both wrappers (same pattern as route_usp_dashboard.dart).
W-3 [both reviewers, CARRY-OVER] High — _exitEditMode lacks isEditing guard
lib/page/dashboard/providers/dashboard_edit_mode_provider.dart:89
enterEditMode() has the correct re-entrant guard. _exitEditMode still has no symmetric guard. Concurrent path: user taps commit while route onExit fires simultaneously. Both enter _exitEditMode; the second call reaches controller.setEditMode(false) a second time and creates redundant state churn.
Fix:
Future<void> _exitEditMode({required bool revert}) async {
if (!state.isEditing) return; // symmetric guard matching enterEditMode
// ... rest unchanged
}W-4 [single, Reviewer A, CARRY-OVER] Med — Missing isEditing==false idempotency tests
test/page/dashboard/providers/dashboard_edit_mode_provider_test.dart
Tests added this round cover: PeterJhong regression, re-entrant enter guard, async-gap cancel, multi-cycle. None test calling commitEditMode() or cancelEditMode() when isEditing == false. (Reviewer A read the test file directly and confirmed absence; Reviewer B marked resolved without access to the PR branch.)
Fix: Add two tests verifying safe no-op behavior when not in edit mode.
W-7 [single, Reviewer A, NEW] High — Widget added via settings panel then Cancel reverts it
lib/page/dashboard/views/usp_sliver_dashboard_view.dart:514-519 + usp_layout_settings_panel.dart
if ((result == 'reset' || result == 'preset_changed') && mounted) {
await ref.read(dashboardEditModeProvider.notifier).commitEditMode();
}When the settings panel Close button pops with no argument, result == null. Condition is false; commitEditMode() not called. Inside the panel, addWidget() calls saveLayout() immediately — widget is persisted to disk. But snapshot captured in enterEditMode() does not include the new widget. If user then taps Cancel, cancelEditMode() restores the snapshot — widget removed from memory and disk despite having been saved.
Concrete trigger: Edit mode → Settings panel → "+" add widget → Close (no result) → tap Cancel → widget disappears.
Fix (Option 1): Pop with 'widget_added' after addWidget() in the settings panel; add that string to the condition in _openLayoutSettings.
Fix (Option 2): Add refreshSnapshot() to the notifier and call it after widget mutations inside the settings panel.
What looks good
- PeterJhong regression correctly fixed —
commitEditMode()preserves reset/preset changes. Regression test added. enterEditModere-entrant guard — correct order: guard, early state claim, await, post-await bail-out.try/finallyguarantees no stuck edit mode —setEditMode(false)and state always cleared regardless of I/O failures.- Route
onExiterror handling — try/catch with structured logging; navigation never blocked. - Type narrowing —
List<dynamic>toList<Map<String, dynamic>>surfaces type errors at compile time. linksys_route.dartdeletion — dead file using deprecatedFeatureState.isDirtycorrectly removed.- New tests — PeterJhong regression, re-entrant guard, async-gap cancel, multi-cycle tests well-targeted.
- No security issues — no hardcoded secrets, injection vectors, or JNAP/USP response handling modifications.
- Architecture compliance —
NotifierProvider(non-autoDispose),ref.readinside notifier, 3-layer boundaries maintained.
Cross-reviewed by two independent agents (security+correctness / architecture+maintainability). Automated — please sanity-check before merge.
AustinChangLinksys
left a comment
There was a problem hiding this comment.
🤖 Automated Review — Round 4 · c513381..963db6d (incremental)
Verdict: 💬 Self-review (comment only) — GitHub prohibits self-approval. PeterJhong's regression (_openLayoutSettings revert path) is correctly fixed this round. Four carry-over Warnings remain (W-1, W-2, W-3, W-4); W-7 is contested — one reviewer considers it resolved, the other sees a residual data-loss edge case.
| Conf. | Where | Issue (one-liner) | |
|---|---|---|---|
| 🟢High | dashboard_edit_mode_provider.dart:93-104 |
[both reviewers] saveLayout() exception skips restoreSnapshot() — grid reverted in-memory but prefs left inconsistent |
|
| 🟢High | usp_sliver_dashboard_view.dart:115-121 |
[both reviewers] _commitEditMode/_cancelEditMode remain void async without try/catch — UI button exceptions silently discarded |
|
| 🟢High | dashboard_edit_mode_provider.dart:89 |
[both reviewers] _exitEditMode lacks isEditing guard — double-call (button + route onExit) can execute two concurrent reverts |
|
| 🟡Med | test/…/dashboard_edit_mode_provider_test.dart |
[both reviewers] No isEditing==false idempotency tests for commitEditMode/cancelEditMode |
|
| 🟡Med | usp_sliver_dashboard_view.dart:514 + settings panel |
[single — contested] Widget added via + in settings panel, then Close (null result) + Cancel = widget silently dropped (Reviewer B considers this correct cancel semantics) |
Confidence: 🟢High = code-verified · 🟡Med = located + reasoned, not fully confirmed · ⚪Low = speculative, please double-check.
Items marked [both reviewers] were independently flagged by two agents → higher confidence.
✅ Resolved this round
- PeterJhong regression RESOLVED —
_openLayoutSettings(line 514) now callscommitEditMode()forresetandpreset_changedresults, correctly keeping the applied change instead of reverting to pre-edit snapshot. Regression test added. toggle_offremoval confirmed safe — global search finds zero remainingtoggle_offstrings in .dart files; no matching pop calls exist.linksys_route.dartdeletion confirmed safe —LinksysRouteclass was already migrated toroute_model.dartusingPreservableContract; no dangling imports found.DashboardEditStatetype narrowing —layoutSnapshotnow typedList<Map<String, dynamic>>?(narrowed fromList<dynamic>?) andcopyWithupdated consistently.enterEditModere-entrant guard + async-gap bail-out —if (state.isEditing) returnat line 50 plusif (!state.isEditing) returnpost-await at line 60 correctly handle double-tap race and navigation-during-init.
⚠️ Warning Details
W-1 [both reviewers, CARRY-OVER] 🟢High — saveLayout() exception skips restoreSnapshot()
lib/page/dashboard/providers/dashboard_edit_mode_provider.dart:93-104
try {
if (revert) {
if (state.layoutSnapshot != null) {
controller.importLayout(state.layoutSnapshot!);
await ref.read(uspSliverDashboardControllerProvider.notifier).saveLayout();
// if this throws, restoreSnapshot() below is NEVER reached
}
if (state.prefsSnapshot != null) {
await ref.read(uspLayoutPreferencesProvider.notifier).restoreSnapshot(state.prefsSnapshot!);
}
}
} finally {
controller.setEditMode(false);
state = const DashboardEditState(); // snapshots wiped; no retry possible
}The finally block correctly prevents stuck edit mode. However, if saveLayout() throws (SharedPreferences/localStorage I/O failure), restoreSnapshot() is skipped. Post-failure: grid in-memory is reverted to snapshot, grid on-disk still reflects edited state, prefs still reflect edit-mode values. Three subsystems are now inconsistent; snapshots wiped in finally so no retry is possible.
Fix: Give each awaitable its own try/catch so restoreSnapshot always runs:
if (revert) {
if (state.layoutSnapshot != null) {
controller.importLayout(state.layoutSnapshot!);
try {
await ref.read(uspSliverDashboardControllerProvider.notifier).saveLayout();
} catch (e, s) {
logger.e('[Dashboard] saveLayout failed during cancel', error: e, stackTrace: s);
}
}
if (state.prefsSnapshot != null) {
await ref.read(uspLayoutPreferencesProvider.notifier).restoreSnapshot(state.prefsSnapshot!);
}
}W-2 [both reviewers, CARRY-OVER] 🟢High — _commitEditMode/_cancelEditMode remain void async
lib/page/dashboard/views/usp_sliver_dashboard_view.dart:115-121
void _commitEditMode() async {
await ref.read(dashboardEditModeProvider.notifier).commitEditMode();
}
void _cancelEditMode() async {
await ref.read(dashboardEditModeProvider.notifier).cancelEditMode();
}Both widget-layer wrappers are void async without try/catch. Any exception from the provider is silently discarded. The route's onExit wrapper (route_usp_dashboard.dart:35-44) correctly catches and logs — button-tap paths do not.
Fix: Add try/catch with logger.e (matching route pattern):
void _commitEditMode() async {
try {
await ref.read(dashboardEditModeProvider.notifier).commitEditMode();
} catch (e, s) {
logger.e('[Dashboard] commitEditMode failed', error: e, stackTrace: s);
}
}W-3 [both reviewers, CARRY-OVER] 🟢High — _exitEditMode lacks isEditing guard
lib/page/dashboard/providers/dashboard_edit_mode_provider.dart:89
enterEditMode() has a re-entrant guard (if (state.isEditing) return). _exitEditMode has no symmetric guard. Double-call scenario: user taps Cancel button while route onExit fires simultaneously. Both calls enter _exitEditMode with state.isEditing == true; both execute importLayout() + saveLayout() in parallel, racing on the same storage key. Second call also sees non-null prefsSnapshot (first call's finally hasn't run yet) and triggers a second restoreSnapshot.
Fix:
Future<void> _exitEditMode({required bool revert}) async {
if (!state.isEditing) return; // symmetric guard matching enterEditMode
// ... rest unchanged
}W-4 [both reviewers, CARRY-OVER] 🟡Med — Missing isEditing==false idempotency tests
test/page/dashboard/providers/dashboard_edit_mode_provider_test.dart
New tests this round cover re-entrant enter, async-gap cancel, and commit-preserves-changes. None test calling commitEditMode() or cancelEditMode() when isEditing == false — the no-op/idempotency behavior when not in edit mode is not verified.
Fix: Add two tests:
test('commitEditMode when not editing is a no-op', () async {
final container = await createContainer();
await container.read(dashboardEditModeProvider.notifier).commitEditMode();
expect(container.read(dashboardEditModeProvider).isEditing, isFalse);
});
test('cancelEditMode when not editing is a no-op', () async {
final container = await createContainer();
await container.read(dashboardEditModeProvider.notifier).cancelEditMode();
expect(container.read(dashboardEditModeProvider).isEditing, isFalse);
});W-7 [single — CONTESTED] 🟡Med — Widget added via settings panel then Cancel
lib/page/dashboard/views/usp_sliver_dashboard_view.dart:514-518
if ((result == 'reset' || result == 'preset_changed') && mounted) {
await ref.read(dashboardEditModeProvider.notifier).commitEditMode();
}
// When user adds a widget (+) then closes the panel (result == null):
// commitEditMode() is NOT called; if user taps Cancel → widget revertedReviewer A: Data-loss edge case. The user adds a widget (persisted to disk by addWidget()), closes the panel without triggering 'reset'/'preset_changed', then taps Cancel — the widget disappears despite having been explicitly added.
Reviewer B: Cancel is designed to revert all edit-mode changes to the pre-edit snapshot. A widget added during edit mode reverting on Cancel is correct semantics; the snapshot accurately reflects the pre-edit state.
Synthesizer note: Austin to decide — should widgets added via the settings panel be treated as committed sub-actions that survive a subsequent Cancel? If yes, add a refreshSnapshot() call after addWidget(). If Cancel should always restore the pre-edit baseline, current behavior is correct.
✅ What looks good
_exitEditModefinally block —finallyguaranteescontroller.setEditMode(false)+ state reset regardless of exceptions; dashboard cannot be stranded in edit mode.enterEditModedouble-guard pattern (lines 50 + 60) — claimisEditing: truebefore the firstawait; bail out post-await if cancelled. Both guards are correct and well-commented.commitEditMode/cancelEditModesemantic rename — eliminates thesave: falseinverted-boolean trap; API intent is now unambiguous.- PeterJhong regression fix —
commitEditMode()used correctly for reset/preset_changed path;toggle_offdead branch cleanly removed. layoutSnapshottype narrowing —List<dynamic>?→List<Map<String, dynamic>>?reduces implicit cast risk.route_usp_dashboard.dartonExittry/catch — catches and logs without blocking navigation; correct strategy.- New regression test suite — re-entrant enter, async-gap cancel, and commit-preserves-changes tests are high-quality behavioral regression guards.
linksys_route.dartdeletion — dead code removed cleanly.- Security — no hardcoded secrets, no injection vectors, no permission changes, no JNAP/USP response handling modifications.
Cross-reviewed by two independent agents (security+correctness / architecture+maintainability). Automated — please sanity-check before merge.
AustinChangLinksys
left a comment
There was a problem hiding this comment.
🤖 Automated Review — Round 5 · c513381..963db6d (incremental)
Verdict: 💬 Self-review (comment only) — GitHub prohibits self-approval. PeterJhong's regression (_openLayoutSettings revert path) is correctly fixed this round. Four carry-over Warnings remain open (W-1, W-2, W-3, W-4); one new Warning from architecture review (ARCH-04).
| Conf. | Where | Issue (one-liner) | |
|---|---|---|---|
| 🟢High | dashboard_edit_mode_provider.dart:93-104 |
[both reviewers, CARRY-OVER W-1] saveLayout() exception still skips restoreSnapshot() — prefs diverge from grid on I/O failure |
|
| 🟢High | usp_sliver_dashboard_view.dart:115-121 |
[both reviewers, CARRY-OVER W-2] _commitEditMode/_cancelEditMode are still void async without try/catch — button-tap exceptions silently discarded |
|
| 🟢High | dashboard_edit_mode_provider.dart:89 |
[both reviewers, CARRY-OVER W-3] _exitEditMode has no isEditing guard — double-call (button + route onExit) still races on SharedPreferences write |
|
| 🟡Med | test/…/dashboard_edit_mode_provider_test.dart |
[both reviewers, CARRY-OVER W-4] No idempotency tests for commitEditMode/cancelEditMode when isEditing == false |
|
| 🟡Med | usp_sliver_dashboard_view.dart:514 |
[single — architecture] _openLayoutSettings calls commitEditMode() on reset/preset_changed — silently exits edit mode; user cannot keep adjusting layout after applying a preset |
|
| 💡 | 🟢High | dashboard_edit_mode_provider.dart:52-65 |
isEditing: true emitted ~100ms before controller.setEditMode(true) — brief window where edit UI shows but drags are silently rejected |
| 💡 | 🟢High | dashboard_edit_mode_provider.dart (_exitEditMode) |
controller.setEditMode(false) called unconditionally even when !isEditing — add a comment documenting intent (or add guard, resolves W-3) |
| 💡 | 🟡Med | lib/route/linksys_route.dart (deleted) |
Deletion is safe (no imports confirmed) — run flutter analyze to confirm no build-target artifacts |
Confidence: 🟢High = code-verified · 🟡Med = located + reasoned, not fully confirmed · ⚪Low = speculative, please double-check.
Items marked [both reviewers] were independently flagged by two agents → higher confidence.
✅ Resolved this round
- PeterJhong regression RESOLVED —
_openLayoutSettings(line 514) now callscommitEditMode()forresetandpreset_changedresults, correctly keeping the applied change instead of reverting to pre-edit snapshot.toggle_offbranch removed (confirmed dead — settings panel never pops this value). - W-1 controller cleanup RESOLVED —
_exitEditModewraps work intry/finally;controller.setEditMode(false)andstate = const DashboardEditState()are now guaranteed to execute even whensaveLayout()orrestoreSnapshot()throws. Dashboard can no longer be stranded in edit mode on I/O failure. - Re-entrant
enterEditModeRESOLVED —if (state.isEditing) returnguard at line 50 prevents double-tap/gesture race from overwriting the original snapshot. Post-awaitif (!state.isEditing) returnbail-out correctly handles cancellation during theinitializedawait. DashboardEditState.layoutSnapshottype tightened — narrowed fromList<dynamic>?toList<Map<String, dynamic>>?. Verified thatsliver_dashboard 0.9.1declaresList<Map<String, dynamic>> exportLayout()— this narrowing is safe and compiles cleanly.- New regression tests added —
commitEditMode-preserves-changes(PeterJhong regression guard), re-entrant-enter (W-1 guard), cancel-during-async-gap (W-2 guard). linksys_route.dartdeletion confirmed safe —LinksysRouteactive implementation lives inroute_model.dart; zero imports of the deleted file across the codebase.
⚠️ Warning Details
W-1 [both reviewers, CARRY-OVER] 🟢High — saveLayout() throw still skips restoreSnapshot()
lib/page/dashboard/providers/dashboard_edit_mode_provider.dart:93-104
try {
if (revert) {
if (state.layoutSnapshot != null) {
controller.importLayout(state.layoutSnapshot!);
await ref.read(...).saveLayout(); // ← if THIS throws …
}
if (state.prefsSnapshot != null) {
await ref.read(...).restoreSnapshot(...); // ← … STILL skipped
}
}
} finally {
controller.setEditMode(false); // ✅ now guaranteed
state = const DashboardEditState(); // ✅ now guaranteed
}The finally block correctly prevents stuck edit mode (improvement). But if saveLayout() throws (SharedPreferences/localStorage I/O failure), restoreSnapshot() is skipped. Post-failure: grid in-memory is reverted to snapshot, grid on-disk still reflects edited state, prefs (selectedPreset) still reflect the in-progress edit. Three subsystems are inconsistent; snapshots wiped in finally so no retry possible.
Fix: Give each awaitable its own try/catch so restoreSnapshot always runs regardless of saveLayout success:
if (state.layoutSnapshot != null) {
controller.importLayout(state.layoutSnapshot!);
try {
await ref.read(uspSliverDashboardControllerProvider.notifier).saveLayout();
} catch (e, s) {
logger.e('[Dashboard] saveLayout failed during cancel', error: e, stackTrace: s);
}
}
if (state.prefsSnapshot != null) {
await ref.read(uspLayoutPreferencesProvider.notifier).restoreSnapshot(state.prefsSnapshot!);
}W-2 [both reviewers, CARRY-OVER] 🟢High — _commitEditMode/_cancelEditMode bare void async
lib/page/dashboard/views/usp_sliver_dashboard_view.dart:115-121
void _commitEditMode() async {
await ref.read(dashboardEditModeProvider.notifier).commitEditMode(); // no try/catch
}
void _cancelEditMode() async {
await ref.read(dashboardEditModeProvider.notifier).cancelEditMode(); // no try/catch
}The provider's finally block prevents state corruption, but a saveLayout() I/O failure during revert will propagate out through cancelEditMode() → _cancelEditMode() → silently consumed by Flutter's unhandled-async zone. The route guard (same commit) correctly wraps the same call with try/catch + logger.e. The two call-sites have inconsistent error-handling discipline.
Fix: Add try/catch matching the route guard pattern:
void _cancelEditMode() async {
try {
await ref.read(dashboardEditModeProvider.notifier).cancelEditMode();
} catch (e, s) {
logger.e('[Dashboard] cancelEditMode failed', error: e, stackTrace: s);
}
}W-3 [both reviewers, CARRY-OVER] 🟢High — _exitEditMode lacks isEditing guard
lib/page/dashboard/providers/dashboard_edit_mode_provider.dart:89
enterEditMode() now has a symmetric re-entrant guard (if (state.isEditing) return). _exitEditMode has no matching if (!state.isEditing) return.
Double-exit scenario: user taps ✗ Cancel while route onExit fires simultaneously. Both calls enter _exitEditMode(revert: true) with state.isEditing == true. Both read state.layoutSnapshot (non-null — first call's finally has not yet run), both execute importLayout() + saveLayout() concurrently, racing on the same SharedPreferences key.
Fix:
Future<void> _exitEditMode({required bool revert}) async {
if (!state.isEditing) return; // symmetric guard matching enterEditMode
// ... rest unchanged
}W-4 [both reviewers, CARRY-OVER] 🟡Med — Missing idempotency tests
test/page/dashboard/providers/dashboard_edit_mode_provider_test.dart
New tests cover re-entrant enter, async-gap cancel, and commit-preserves-changes. None verify that calling commitEditMode() or cancelEditMode() when isEditing == false is a no-op (or at minimum doesn't corrupt state). Without such a test, the W-3 double-call scenario and the controller.setEditMode(false) unconditional call are invisible to CI.
Fix: Add two tests:
test('commitEditMode when not editing is a no-op', () async { ... });
test('cancelEditMode when not editing is a no-op', () async { ... });ARCH-04 [single — architecture] 🟡Med — _openLayoutSettings exits edit mode on reset/preset_changed
lib/page/dashboard/views/usp_sliver_dashboard_view.dart:514
if ((result == 'reset' || result == 'preset_changed') && mounted) {
// Commit (keep) the change — the settings panel already applied the
// reset/preset directly to the controller and prefs …
await ref.read(dashboardEditModeProvider.notifier).commitEditMode();
}commitEditMode() exits edit mode entirely. A user who opens Settings → selects a new preset → closes the panel is silently returned to the non-editing dashboard state, instead of back to edit mode. The bug that PeterJhong flagged (changes were reverted) is fixed, but the flow now ejects the user from edit mode without indication. This is a second-order UX regression — consider returning null from the panel close to keep edit mode alive, rather than terminating it, unless fully exiting is the intended design.
✅ What looks good
- No security issues — no hardcoded secrets, no injection vectors, no JNAP/USP response handling touched.
finallyblock is correct — state always resets, edit mode never gets stranded on I/O failure.- Re-entrant
enterEditModeguard —if (state.isEditing) return+ pre-awaitisEditing: trueclaim + post-await bail-out is the right pattern for this async lifecycle. commitEditMode()/cancelEditMode()naming — explicit names are clearer thanexitEditMode({bool save})which was an inverted-boolean trap.- Route
onExithardened correctly — try/catch +logger.e+ always returnstrue(never blocks navigation) is the correct pattern. - Test coverage for the regression —
commitEditMode-preserves-changesdirectly guards against the PeterJhong regression reoccurring. layoutSnapshottype tightening —List<Map<String, dynamic>>?is correct; verified againstsliver_dashboard 0.9.1which declaresList<Map<String, dynamic>> exportLayout().
Cross-reviewed by two independent agents (security+correctness / architecture+maintainability). Automated — please sanity-check before merge.
AustinChangLinksys
left a comment
There was a problem hiding this comment.
🤖 Automated Review — Round 6 · c513381..963db6d (incremental)
Verdict: 💬 Self-review (comment only) — GitHub prohibits self-approval. Three carry-over Warnings from Round 5 are now fully resolved (W-1, W-2, W-3). ARCH-04 is resolved. W-4 (idempotency tests) remains open. Two new Warnings raised by both reviewers independently; two additional Warnings from single reviewers.
| Conf. | Where | Issue (one-liner) | |
|---|---|---|---|
| 🟢High | dashboard_edit_mode_provider.dart:92-104 |
[both reviewers] saveLayout() throw inside single try still skips restoreSnapshot() — prefs diverge from grid on I/O failure |
|
| 🟢High | usp_sliver_dashboard_view.dart:115-121 |
[both reviewers] _commitEditMode/_cancelEditMode are void async with no error logging — I/O failures silently discarded at view layer |
|
| 🟡Med | dashboard_edit_mode_provider.dart:89 |
[single — architecture] _exitEditMode has no isEditing guard — called outside edit mode causes spurious Riverpod rebuilds every tab-leave |
|
| 🟡Med | dashboard_edit_mode_provider.dart:54-60 |
[single — security] enterEditMode captures snapshots after await initialized; cancel during async gap skips revert (null snapshots) |
|
| 🟡Med | test/…/dashboard_edit_mode_provider_test.dart |
[both reviewers, CARRY-OVER W-4] No idempotency tests for commitEditMode/cancelEditMode when isEditing == false |
|
| 💡 | 🟢High | usp_layout_settings_panel.dart:127 |
as Map cast inconsistent with PR's type-tightening to Map<String, dynamic> — use e['id'] directly |
| 💡 | 🟡Med | lib/route/linksys_route.dart (deleted) |
PR description says "removed" but LinksysRoute lives on in route_model.dart — misleading commit note |
Confidence: 🟢High = code-verified · 🟡Med = located + reasoned, not fully confirmed · ⚪Low = speculative, please double-check.
Items marked [both reviewers] were independently flagged by two agents → higher confidence.
✅ Resolved this round (W-1, W-2, W-3, ARCH-04)
- W-1 [both reviewers] RESOLVED —
_exitEditModenow wraps both revert operations intry/finally.controller.setEditMode(false)andstate = DashboardEditState()are guaranteed to run even ifsaveLayout()orrestoreSnapshot()throws — dashboard can no longer be stranded in edit mode on I/O failure. (Residual: W-1 carry-over re-emerges as N-1 below — thefinallysolves "stuck in edit mode" but not "prefs diverge from grid".) - W-2 [both reviewers] RESOLVED — State integrity is now owned by the provider layer (
try/finally). The view's_commitEditMode/_cancelEditModecorrectly delegate; provider guarantees state cleanup. Route-levelonExithas its owntry/catch+logger.efor the high-stakes path. (New logging gap remains — see N-2.) - W-3 [both reviewers] RESOLVED —
enterEditMode()has re-entrant guard at line 50 (if (state.isEditing) return) and post-await bail-out at line 60 (if (!state.isEditing) return). Both synchronous double-tap race and navigation-during-async-gap race are covered. Calling_exitEditModewhenisEditing == falseis safe (revert path short-circuits on null snapshots;finallywrites are idempotent). (Spurious rebuild concern noted as N-3.) - ARCH-04 [single — architecture] RESOLVED —
_openLayoutSettingsnow callscommitEditMode()(keep) instead ofexitEditMode(save: false)(revert) forreset/preset_changedresults.toggle_offbranch correctly removed (confirmed no hit in codebase — was always a dead path). PeterJhong's regression is fixed. - Re-entrant
enterEditModeRESOLVED — Pre-awaitisEditing: trueclaim + post-await bail-out correctly handles both the double-tap race and the navigate-during-init-gap scenario. layoutSnapshottype tightened — Narrowed fromList<dynamic>?toList<Map<String, dynamic>>?; consistent withsliver_dashboard 0.9.1'sexportLayout()return type.- PeterJhong regression fully addressed —
commitEditMode-preserves-changestest directly guards against recurrence.
⚠️ Warning Details
N-1 [both reviewers] 🟢High — Sequential revert steps share one try; saveLayout() throw still skips restoreSnapshot()
lib/page/dashboard/providers/dashboard_edit_mode_provider.dart:92–104
try {
if (revert) {
if (state.layoutSnapshot != null) {
controller.importLayout(state.layoutSnapshot!);
await ref.read(uspSliverDashboardControllerProvider.notifier).saveLayout(); // ← if this throws…
}
if (state.prefsSnapshot != null) {
await ref.read(uspLayoutPreferencesProvider.notifier).restoreSnapshot(state.prefsSnapshot!); // ← …skipped
}
}
} finally {
controller.setEditMode(false); // ✅ always runs
state = const DashboardEditState(); // ✅ always runs
}The finally correctly prevents stuck edit mode. But when saveLayout() throws (SharedPreferences/localStorage I/O failure during cancel), restoreSnapshot() is never reached. Post-failure: grid is reverted in-memory, grid on-disk still reflects edited state, prefs (selectedPreset) still reflect the in-progress edit — three subsystems inconsistent. Snapshots wiped in finally so no retry possible.
Trigger: SharedPreferences I/O failure (quota exceeded, corrupted storage, race with another write) during cancel-edit-mode on Android/iOS.
Fix: Wrap each awaitable independently:
if (state.layoutSnapshot != null) {
controller.importLayout(state.layoutSnapshot!);
try {
await ref.read(uspSliverDashboardControllerProvider.notifier).saveLayout();
} catch (e, s) {
logger.e('[Dashboard] saveLayout failed during revert', error: e, stackTrace: s);
}
}
if (state.prefsSnapshot != null) {
await ref.read(uspLayoutPreferencesProvider.notifier).restoreSnapshot(state.prefsSnapshot!);
}N-2 [both reviewers] 🟢High — _commitEditMode/_cancelEditMode bare void async with no error logging
lib/page/dashboard/views/usp_sliver_dashboard_view.dart:115–121
void _commitEditMode() async {
await ref.read(dashboardEditModeProvider.notifier).commitEditMode(); // no try/catch
}
void _cancelEditMode() async {
await ref.read(dashboardEditModeProvider.notifier).cancelEditMode(); // no try/catch
}Provider's finally prevents state corruption but any exception escaping commitEditMode()/cancelEditMode() propagates into the void async frame and is silently consumed by Flutter's unhandled-async zone — no log, no user feedback. The route guard (route_usp_dashboard.dart:27–35) has try/catch + logger.e; the two button-tap call sites have inconsistent error-handling discipline.
Fix: Add try/catch matching the route guard pattern:
void _cancelEditMode() async {
try {
await ref.read(dashboardEditModeProvider.notifier).cancelEditMode();
} catch (e, s) {
logger.e('[Dashboard] cancelEditMode failed', error: e, stackTrace: s);
}
}
void _commitEditMode() async {
try {
await ref.read(dashboardEditModeProvider.notifier).commitEditMode();
} catch (e, s) {
logger.e('[Dashboard] commitEditMode failed', error: e, stackTrace: s);
}
}N-3 [single — architecture] 🟡Med — _exitEditMode lacks isEditing guard; spurious Riverpod rebuilds on every tab-leave
lib/page/dashboard/providers/dashboard_edit_mode_provider.dart:89
enterEditMode() has a symmetric re-entrant guard (if (state.isEditing) return). _exitEditMode has no matching if (!state.isEditing) return. When called outside edit mode (e.g., if a route guard fires on a tab where the user never entered edit mode), the finally block unconditionally calls controller.setEditMode(false) and assigns state = const DashboardEditState() — triggering Riverpod listener rebuilds even when nothing changed.
Fix:
Future<void> _exitEditMode({required bool revert}) async {
if (!state.isEditing) return; // symmetric guard matching enterEditMode
// ... rest unchanged
}Note: route_usp_dashboard.dart:34 already gates if (editState.isEditing) before calling cancelEditMode(), so the route path is safe. The guard in the provider itself would protect future callers.
N-4 [single — security] 🟡Med — Snapshot is null during await initialized async gap; cancel during gap skips layout revert
lib/page/dashboard/providers/dashboard_edit_mode_provider.dart:54–60
// Line 54: isEditing = true, layoutSnapshot = null, prefsSnapshot = null
state = const DashboardEditState(isEditing: true);
await ref.read(uspLayoutPreferencesProvider.notifier).initialized; // async gap
// Line 60: post-await bailout
if (!state.isEditing) return;
// Lines 62-70: snapshots captured HERE (after await)
final layoutSnapshot = controller.exportLayout();If cancelEditMode() fires in the async gap (navigation away before initialized resolves), _exitEditMode(revert: true) runs with state.layoutSnapshot == null — the if (state.layoutSnapshot != null) guard short-circuits, so importLayout + restoreSnapshot are skipped. Any grid changes made between enterEditMode() and initialized resolving will not be reverted.
Trigger: User taps Edit on first launch (prefs cold, initialized is slow) → modifies a card → navigates away before prefs resolve. Grid state is not reverted.
Fix: Capture the layout snapshot synchronously before the await:
final controller = ref.read(uspSliverDashboardControllerProvider);
final earlyLayoutSnapshot = controller.exportLayout();
final earlyPrefsSnapshot = ref.read(uspLayoutPreferencesProvider);
state = DashboardEditState(isEditing: true, layoutSnapshot: earlyLayoutSnapshot, prefsSnapshot: earlyPrefsSnapshot);
await ref.read(uspLayoutPreferencesProvider.notifier).initialized;
if (!state.isEditing) return;
// Refresh prefs snapshot post-initialization
final refreshedPrefs = ref.read(uspLayoutPreferencesProvider);
state = state.copyWith(prefsSnapshot: refreshedPrefs);
controller.setEditMode(true);W-4 [both reviewers, CARRY-OVER] 🟡Med — No idempotency tests for commitEditMode/cancelEditMode when isEditing == false
test/page/dashboard/providers/dashboard_edit_mode_provider_test.dart
New tests cover re-entrant enter, async-gap cancel, commit-preserves-changes, and multiple enter/commit cycles. None verify that calling commitEditMode() or cancelEditMode() when isEditing == false is a safe no-op (does not corrupt state or throw). The behavioral contract is untested; a future refactor adding a guard would have no regression safety net.
Fix:
test('commitEditMode when not editing is a no-op', () async {
final container = await createContainer();
addTearDown(container.dispose);
await container.read(dashboardEditModeProvider.notifier).commitEditMode();
final state = container.read(dashboardEditModeProvider);
expect(state.isEditing, isFalse);
expect(state.layoutSnapshot, isNull);
});
test('cancelEditMode when not editing is a no-op', () async { ... });✅ What looks good
- No security issues — no hardcoded secrets, no injection vectors, no JNAP/USP response handling touched.
finallyblock is correct — state always resets, edit mode never gets stranded on I/O failure.- Re-entrant
enterEditModeguard —if (state.isEditing) return+ pre-awaitisEditing: trueclaim + post-await bail-out is the right pattern for this async lifecycle. commitEditMode()/cancelEditMode()naming — explicit names are clearer thanexitEditMode({bool save})inverted-boolean trap.- Route
onExithardened correctly —try/catch + logger.e + always return trueis the correct pattern. _openLayoutSettingsfix is correct —commitEditMode()forreset/preset_changedpreserves the applied change;toggle_offremoval confirmed dead path.- Test coverage for regression —
commitEditMode-preserves-changesdirectly guards against PeterJhong regression. layoutSnapshottype tightening —List<Map<String, dynamic>>?correct; consistent withsliver_dashboard 0.9.1.linksys_route.dartdeletion —LinksysRoutelives on inroute_model.dart; file was a standalone re-export that is correctly consolidated.- PeterJhong regression addressed —
_openLayoutSettingsnow callscommitEditMode()(keep) forreset/preset_changedresults.
Cross-reviewed by two independent agents (security+correctness / architecture+maintainability). Automated — please sanity-check before merge.
AustinChangLinksys
left a comment
There was a problem hiding this comment.
🤖 Automated Review — Round 2 · c513381..963db6d (incremental)
Verdict: 💬 Self-review (comment only) — GitHub prohibits self-approval. PeterJhong's _openLayoutSettings bug (calling revert instead of commit on reset/preset) is correctly fixed in this round. W4 (async-gap race) is also resolved. Four prior Warnings remain open; two new Warnings and two Suggestions identified.
| Conf. | Where | Issue (one-liner) | |
|---|---|---|---|
| 🟢High | dashboard_edit_mode_provider.dart:92–109 |
[both reviewers] saveLayout() throw inside single try still skips restoreSnapshot() — partial revert on I/O failure leaves grid/prefs inconsistent |
|
| 🟢High | usp_sliver_dashboard_view.dart:115–121 |
[both reviewers] _commitEditMode/_cancelEditMode button-tap handlers are void async with no try/catch — I/O failures silently discarded at view layer |
|
| 🟡Med | dashboard_edit_mode_provider.dart (N1) |
[both reviewers] Edit UI renders (isEditing=true) before controller.setEditMode(true) — drag/resize gestures silently rejected during async gap on slow devices |
|
| 🟡Med | dashboard_edit_mode_provider.dart:54–72 |
[single — security] Snapshots captured only after await initialized; if cancelled during gap, setEditMode(false) called on a controller never put into edit mode |
|
| 💡 | 🟢High | dashboard_edit_mode_provider.dart |
[both reviewers, CARRY-OVER W-5] No idempotency tests for commitEditMode/cancelEditMode when isEditing == false |
| 💡 | 🟢High | dashboard_edit_mode_provider.dart |
[single — architecture] _exitEditMode has no isEditing guard — called outside edit mode causes spurious Riverpod state emission and unnecessary setEditMode(false) call |
| 💡 | 🟢High | usp_sliver_dashboard_view.dart:499–519 |
[single — architecture] _openLayoutSettings null-result (dialog dismissed without reset/preset) leaves user in edit mode with no documentation of this design intent |
| 💡 | 🟡Med | usp_layout_settings_panel.dart:127 |
[single] (e as Map)['id'] cast inconsistent with PR's type-tightening of layoutSnapshot to List<Map<String, dynamic>>? — erased type risks TypeError if id absent |
| 💡 | ⚪Low | test/…/dashboard_edit_mode_provider_test.dart |
[single — architecture] No widget test for _openLayoutSettings → commitEditMode() path — PeterJhong regression unguarded at view layer |
Confidence: 🟢High = code-verified · 🟡Med = located + reasoned, not fully confirmed · ⚪Low = speculative, please double-check.
Items marked [both reviewers] were independently flagged by two agents → higher confidence.
⚠️ Warning Details
W-1 · [both reviewers] 🟢High — saveLayout() throw skips restoreSnapshot() on cancel path
lib/page/dashboard/providers/dashboard_edit_mode_provider.dart:92–109
try {
if (revert) {
if (state.layoutSnapshot != null) {
controller.importLayout(state.layoutSnapshot!); // grid mutated in-memory
await ref
.read(uspSliverDashboardControllerProvider.notifier)
.saveLayout(); // ← if this throws…
}
if (state.prefsSnapshot != null) {
await ref
.read(uspLayoutPreferencesProvider.notifier)
.restoreSnapshot(state.prefsSnapshot!); // …this is never reached
}
}
} finally {
controller.setEditMode(false); // ✅ always runs
state = const DashboardEditState();
}Trigger: SharedPreferences/localStorage I/O failure (quota exceeded, corrupted storage, write race) during saveLayout() on Android/iOS while cancelling edit mode.
Consequence: importLayout() has mutated the in-memory grid back to snapshot. saveLayout() throws; restoreSnapshot() is skipped. After finally: edit mode exits correctly (✅), but the persisted layout key reflects the last-drag edit value while UspLayoutPreferences retains the edit-mode prefs — the two subsystems are now diverged. prefsSnapshot is wiped in finally, so no retry is possible on next load.
Fix: Separate the two revert operations into independent try/catch blocks:
if (revert) {
if (state.layoutSnapshot != null) {
controller.importLayout(state.layoutSnapshot!);
try {
await ref.read(uspSliverDashboardControllerProvider.notifier).saveLayout();
} catch (e, s) {
logger.e('[Dashboard] saveLayout failed during revert', error: e, stackTrace: s);
}
}
if (state.prefsSnapshot != null) {
await ref.read(uspLayoutPreferencesProvider.notifier).restoreSnapshot(state.prefsSnapshot!);
}
}W-2 · [both reviewers] 🟢High — View button-tap handlers _commitEditMode/_cancelEditMode swallow exceptions silently
lib/page/dashboard/views/usp_sliver_dashboard_view.dart:115–121
void _commitEditMode() async {
await ref.read(dashboardEditModeProvider.notifier).commitEditMode(); // no try/catch
}
void _cancelEditMode() async {
await ref.read(dashboardEditModeProvider.notifier).cancelEditMode(); // no try/catch
}The route guard at route_usp_dashboard.dart:32–44 correctly wraps cancelEditMode() in try/catch(e, s) with logger.e. The two button-tap call sites are inconsistent — any exception escaping _exitEditMode propagates into the void async frame and is silently consumed by Flutter's unhandled-async zone with no log entry.
Note: Reviewer B considers the route-level fix sufficient since navigation-away is the high-stakes path; Reviewer A flags that the button-tap path (explicit user action) should also be logged. Both views have merit — at minimum, adding try/catch + logger.e (without user-facing error) to _cancelEditMode would be consistent with the route guard discipline.
Fix:
void _cancelEditMode() async {
try {
await ref.read(dashboardEditModeProvider.notifier).cancelEditMode();
} catch (e, s) {
logger.e('[Dashboard] cancelEditMode failed', error: e, stackTrace: s);
}
}W-N1 · [both reviewers] 🟡Med — Edit UI visible before controller.setEditMode(true) during async gap
lib/page/dashboard/providers/dashboard_edit_mode_provider.dart:54–72
state = const DashboardEditState(isEditing: true); // line 54 — UI renders edit mode HERE
// jiggle, trash zone, edit buttons show
await ref.read(uspLayoutPreferencesProvider.notifier).initialized; // line 56 — can be slow
if (!state.isEditing) return; // line 60
final controller = ref.read(uspSliverDashboardControllerProvider);
// ... snapshot capture ...
controller.setEditMode(true); // line 72 — controller enters edit mode ONLY HERETrigger: Slow device or first-launch SharedPreferences initialization. uspLayoutPreferencesProvider.initialized awaits SharedPreferences.getInstance() + JSON deserialization — measurably non-zero on first launch.
Consequence: During the async gap (lines 54–72), isEditing == true causes the build to render the full edit-mode UI (jiggle animation, trash zone, action buttons) while DashboardController.setEditMode(true) has not yet been called. Drag and resize gestures are silently rejected by the controller. This is a visible "broken" window on slow devices. Additionally, if cancelEditMode() fires during this gap, _exitEditMode(revert: true) is called with both snapshots null (not yet captured), so the revert body is skipped entirely — and setEditMode(false) is called on a controller that was never put into edit mode.
Fix: Either defer the isEditing = true claim until after initialization completes, or add an isInitializing phase to suppress edit-mode UI until snapshots are captured:
// Option A: delay claim
await ref.read(uspLayoutPreferencesProvider.notifier).initialized;
if (state.isEditing) return; // double-tap guard still works if re-ordered
state = const DashboardEditState(isEditing: true);
// ... capture snapshots, then controller.setEditMode(true)W-N2 · [single — security] 🟡Med — Cancel-during-async-gap calls setEditMode(false) on non-edit-mode controller
lib/page/dashboard/providers/dashboard_edit_mode_provider.dart:54–72
Related to W-N1: if cancelEditMode() fires after isEditing=true (line 54) but before controller.setEditMode(true) (line 72), _exitEditMode(revert: true) runs through finally and calls controller.setEditMode(false) on a controller that was never put into edit mode via setEditMode(true). Whether DashboardController.setEditMode(false) handles this gracefully (vs. assert/throw) depends on the package implementation. If the controller maintains internal state, this is an unpaired call.
This is partially mitigated by the if (!state.isEditing) return bail-out at line 60 (enterEditMode resumes and exits early), but _exitEditMode itself has no such guard (see W-3 / Suggestion below). The fix from W-N1 Option A would also resolve this.
💡 Suggestion Details
S-W5 · [both reviewers, CARRY-OVER] 🟢High — No idempotency tests for commitEditMode/cancelEditMode when isEditing == false
test/page/dashboard/providers/dashboard_edit_mode_provider_test.dart
New tests cover re-entrant enterEditMode and async-gap cancel. No test exercises commitEditMode() or cancelEditMode() called without a preceding enterEditMode. This is needed to validate the idempotency fix proposed in S-W3.
Suggested test:
test('commitEditMode/cancelEditMode are no-ops when not editing', () async {
final container = await createContainer();
await container.read(dashboardEditModeProvider.notifier).commitEditMode();
expect(container.read(dashboardEditModeProvider).isEditing, isFalse);
await container.read(dashboardEditModeProvider.notifier).cancelEditMode();
expect(container.read(dashboardEditModeProvider).isEditing, isFalse);
});S-W3 · [single — architecture] 🟢High — _exitEditMode has no isEditing guard
lib/page/dashboard/providers/dashboard_edit_mode_provider.dart (finally block)
_exitEditMode unconditionally emits state = const DashboardEditState() and calls controller.setEditMode(false) regardless of whether isEditing was true. Calling commitEditMode() or cancelEditMode() when already not editing (double-tap cancel, race with route-exit cancel) causes a spurious Riverpod rebuild on all ref.watch(dashboardEditModeProvider) subscribers.
Fix: Add at top of _exitEditMode:
if (!state.isEditing) return;S-N3 · [single — architecture] 🟢High — _openLayoutSettings null-result design is implicit
lib/page/dashboard/views/usp_sliver_dashboard_view.dart:499–519
When the settings dialog is dismissed without a 'reset' or 'preset_changed' result (result == null), _openLayoutSettings returns without calling commitEditMode() or cancelEditMode(). The user remains in edit mode. This asymmetry (structural change → auto-exit; no-change dismiss → stay in edit) is intentional but undocumented.
Fix: Add a doc comment explaining the design intent:
/// On 'reset'/'preset_changed': immediately commits to preserve the just-applied
/// structural change. On any other result (null / dialog dismissed): stays in
/// edit mode to allow further fine-tuning before explicit commit or cancel.
Future<void> _openLayoutSettings() async { ... }S-S1 · [single] 🟡Med — Type erasure cast in settings panel
lib/page/usp_layout_settings_panel.dart:127 (not in this diff, but now inconsistent with it)
final currentIds =
currentLayout.map((e) => (e as Map)['id'] as String).toSet();layoutSnapshot in DashboardEditState was tightened from List<dynamic>? to List<Map<String, dynamic>>? by this PR. However, currentLayout here comes from controller.exportLayout() directly (not from the snapshot), which returns List<dynamic>. The as Map + as String double-cast is unguarded — if id is absent or non-String, it throws a TypeError with no fallback.
Fix: Use typed access: (e as Map<String, dynamic>)['id'] as String? with null filtering, or add a null guard.
S-N5 · [single — architecture] ⚪Low — No widget test for _openLayoutSettings commit path
test/…/
The provider-level regression test (commitEditMode preserves layout changes applied during edit) covers the fix at provider level. A widget test for _openLayoutSettings verifying that 'reset'/'preset_changed' results transition isEditing to false while preserving the change would guard against re-introducing the PeterJhong bug at the view layer.
✅ What looks good (resolved this round)
- W-4 (both reviewers) RESOLVED —
isEditing = trueis claimed synchronously beforeawait initialized, and the post-await bail-out at line 60 (if (!state.isEditing) return) correctly handles the scenario wherecancelEditMode()fires during the async gap. Re-entrantenterEditModedouble-tap race is fully covered. - PeterJhong regression FIXED —
_openLayoutSettingsnow callscommitEditMode()(preserve) instead of the oldexitEditMode(save: false)(revert) for'reset'and'preset_changed'results. The'toggle_off'case was also removed — confirmed clean:usp_layout_settings_panel.dartno longer pops with'toggle_off'. Regression testcommitEditMode preserves layout changes applied during editdirectly guards this path. - Type-tightening of
layoutSnapshot—List<dynamic>?→List<Map<String, dynamic>>?is a correct and conservative tightening consistent withexportLayout()'s contract. - Route-level error handling —
route_usp_dashboard.dartnow wrapscancelEditMode()intry/catch(e, s)withlogger.e, matching the required discipline for the high-stakes navigation-away path. linksys_route.dartdeletion —LinksysRouteclass is cleanly consolidated intoroute_model.dart. No dangling imports.- New tests — Re-entrant guard test, async-gap cancel test, and
commitEditMode-preserves-changesregression test all cover the key correctness properties added in this round.
Cross-reviewed by two independent agents (security+correctness / architecture+maintainability). Automated — please sanity-check before merge.
PeterJhongLinksys
left a comment
There was a problem hiding this comment.
Verified the edit-mode state extraction into dashboardEditModeProvider and the route onExit guard (cancel + revert to pre-edit snapshot on navigate-away). Independently confirmed the deletion of lib/route/linksys_route.dart is safe (zero imports; the active LinksysRoute lives in route_model.dart and supports the onExit param), and the referenced controller APIs exist. The remaining open edge-case warnings are pre-existing patterns carried over verbatim, not regressions introduced here. No blocking issues.
Summary
dashboardEditModeProvider)onExithandler to dashboard route that cancels edit mode and reverts unsaved layout changes when user navigates away (e.g., tab switch)DashboardEditModeNotifierTest plan
Closes #1037
🤖 Generated with Claude Code